Distinguish expired, refresh-failed, and absent auth sessions - #160
Distinguish expired, refresh-failed, and absent auth sessions#160jeff-gusto wants to merge 14 commits into
Conversation
An expired access token with a valid refresh token on file was reported as `no_access_token` - "run `gusto auth login`". Logging in mints a new pair and overwrites the stored refresh token, so an agent following that instruction destroyed the state that would have recovered, and every retry left the install worse off than the last. Split the one code into three that call for different actions: - `no_access_token` - nothing on file. Log in. - `session_expired` - expired with no way to renew it locally. Log in. - `token_refresh_failed` - a refresh was attempted and rejected while the refresh token is still on file. Retry the command; only log in if that fails too. All three keep exit code 3, and each names the environment it looked in and the credential slot it read. `token_refresh_failed` lifts the token endpoint's own reason into the message rather than leaving a bare status line. Make the environment visible while we're at it, since it was unavailable anywhere it mattered. `auth whoami` reports it, auth errors carry it in a typed `environment` field, and the three `auth` subcommands document `--env`, its production default, and the fact that credentials are stored per environment. When the requested environment has no usable session but the other one does, the error hints at it - a success under `--env sandbox` followed by a wall in production reads as a broken credential model until something connects the two. Wire up `config.toml`'s `environment` key, which was validated and persisted and then read by nothing, so the recovery that hint recommends actually works. It sits at the bottom of the precedence chain: `--env` > `GUSTO_ENVIRONMENT` > config > production. A corrupt config file warns and is ignored rather than aborting, since failing pre-parse would also block `gusto config reset`. Behavior change worth naming: an expired token with no refresh token used to be sent to the API anyway and 401 back. It is now reported as `session_expired` without a request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
This repo is public, so a ticket key in a comment is a dead reference for any reader who can't resolve it. Each of these drops the parenthetical and keeps the sentence around it - the reasoning was the useful part, not the pointer. One needed rewording rather than deletion, since the key was carrying the sentence's subject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Several comments explained the code by narrating the incident that prompted it - "the bug this ticket exists for", "previously absent", "the old single no_access_token", "a machine sat in this exact shape for over a week". That framing decays: a reader a year out has no ticket, no before-state, and no memory of whose machine it was, and is left with a comment that describes something no longer in the tree. Each now states the constraint as it stands. Same reasoning, anchored to the code instead of to a moment: why the three codes can't be interchanged, why the refresh token is left in place, why whoami has to report the environment, and what state the regression tests pin. Comments and one test name only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Three comments claimed that re-running `auth login` after a refresh failure turns a recoverable state into an unrecoverable one, and that each retry leaves the install worse than the last. Neither is true of this code. `login` calls `store.save` only after the code exchange and the token_info lookup both succeed, and its catch just flushes a failure page and rethrows. So a login either replaces the slot with a working token pair or writes nothing at all - the prior refresh token survives an abandoned or failed attempt. Nothing accumulates across attempts either: client creds are reused rather than re-registered, and there is no partial write to compound. The rule those comments guard is still right, for a different reason. A retry after `refresh_failed` is free and the credential it needs is still on file, while a login needs a human at a browser - which is precisely what an agent on a headless box cannot produce, so pointing there dead-ends instead of recovering. A successful login does invalidate the refresh token it replaces, which matters to anything else holding that credential; it does not harm this install. Pin the invariant the retry advice depends on: a failed login must leave an existing session's tokens intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Four review fixes, all in the taxonomy this branch introduces: - getValidUserToken's doc comment claimed it returns null whenever the session can't produce a token, but it throws on a rejected refresh. A caller reduced to null can't tell that state from absence, which is the confusion the branch exists to remove. - whoami's comment claimed to be the only place the environment is reported; failures now carry it in error.environment too. Scoped to success. - sessionFailure's comment named `refresh_failed` alongside two real wire codes, but that one is the internal outcome kind - the wire code is token_refresh_failed. - no_company_uuid set the environment field while its message never named it. Human-mode output prints the message and never that field, so the environment was invisible to half the callers for an error whose own comment calls it decisive. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
A 401 fell through to the generic 4xx bucket: code api_client_error, exit 4, message just the request line. Same code a 422 gets, so nothing distinguished "your credential is no good" from "your request is malformed", and none of the auth taxonomy applied - no environment, no guidance, and an exit code that a caller branching on 3 for credential trouble never sees. It now exits Auth as credential_rejected, alongside the 403 insufficient_scope case that has always classified this way. The message names which credential was refused, because the recovery differs: a stored session can be signed in again, while a token supplied through GUSTO_ACCESS_TOKEN or --token-stdin is the caller's to fix and must not be answered with `auth login`, which would rotate a session the failing command never used. Nothing re-authenticates a rejected token on its own, so no wording suggests a bare retry - the opposite of token_refresh_failed, where the retry is the whole point. The client stamps its credential onto the ApiError it throws, the way it already does for requestId, so every toResult caller reports a 401 identically without threading context through call sites several frames from a resolved context. Behavior change: a 401 exits 3 where it used to exit 4. Two tests asserted the old classification and now assert the new one. README's exit-code line said 4 for all API 4xx, which was already untrue of the 403 scope case; it now says what decides it. Not included: refreshing a rejected token and retrying the request. It only helps where the credential is recoverable, and a server-side revocation still fails - whereas correct classification helps every case. The reactive refresh path in withUserToken remains unwired. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
The within-skew failure path said "the 401 path refreshes later if needed", deferring to `withUserToken`. Nothing reaches that function, so the deferral described a recovery that never happens: the token goes out, comes back 401, and is reported rather than refreshed. Say that, and name where the 401 lands. The neighboring short-circuit justified itself with "a 401 whose message says nothing about why", which classifying 401s made untrue. The reason to name the state locally is now that we already know it, and only the local state can date the expiry and name the slot - not that the alternative is uninformative. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Six comments explained the change being made rather than the code being left behind, which reads fine in review and badly a year later: - The within-skew passthrough cited the absence of a caller for withUserToken. That is a fact about today's call graph, and wiring one up would make the comment wrong while the behavior it describes stayed the same. Says there is no reactive refresh, and what that costs. - The expiry short-circuit compared itself to how a 401 used to read. Compares the two reports on what each can say instead. - The credential on ApiError, its client option, and the type itself justified where they live against the alternative of threading a parameter. Nobody maintaining this needs the road not taken; they need to know the credential rides on the error so distant callers can name it. - The end-to-end 401 test defended its own existence against the unit test. Now says what it guards: a hand-built ApiError satisfies toResult whether or not the wiring that fills it in still holds. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
The persisted `environment` was installed through module state in
global-flags.ts and a `setConfiguredEnvironment` setter called from
main(), so that a synchronous `readGlobalFlags` could reach it. Commander
already resolves this: an explicit `--env` outranks `.env("GUSTO_ENVIRONMENT")`,
which outranks `.default()`. Read the config before building the program
and pass it in as that default.
Drops the mutable module state, the test-only setter, and the per-test
restore discipline it needed. `readGlobalFlags` goes back to passing the
resolved value through, and `defaultEnv` still owns the production
fallback - the default is only installed when one is actually persisted,
so an unset `--env` stays undefined.
The precedence chain was already covered end to end through the compiled
binary in tests/smoke.test.ts, which is where it belongs now that
commander owns it; the unit tests of the setter go away.
Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Stamping `auth` onto the error made the constructor six positional params, two of them optional trailers that read identically at a call site. Group `requestId` and `auth` into a context object; the four required args stay positional. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
`token_refresh_failed` told every caller to "retry the command first, since the retry is free". That holds when the server rejected the attempt, but `invalid_grant` - the reason a revoked or expired refresh token actually comes back with, and the common case - is a verdict on the token, so the retry re-runs the same refresh and fails identically. The advice was wrong for the state it fires on most. Split the message on the reason: `invalid_grant` names the token as rejected and points at `auth login`, noting that the credential the login replaces is already dead, so replacing it costs nothing. Every other reason (a 5xx, `temporarily_unavailable`, a fetch fault) keeps the retry-first wording it was written for. The code is unchanged - what differs is the recovery, not the state a caller branches on. `credential_rejected` had a quieter version of the same problem: for a stored session it sent the caller to `auth login` without saying that this replaces the slot's refresh token, which a 401 does not prove is bad - notably in a slot with no recorded `expiresAt`, where nothing refreshed proactively because nothing knew to. It still points there, since a rerun re-sends the same rejected token and refreshing a rejected credential in place is `withUserToken`'s unwired job, but it now says what the login spends. Verified against a local token-endpoint stub: `invalid_grant` gets the login wording, `temporarily_unavailable` gets the retry wording, and the stored refresh token survives both. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
After `resolveSessionToken` landed, `getValidUserToken` was an adapter that flattened three failure kinds into null-or-throw, and its own doc comment pointed callers at `resolveSessionToken` instead. It has no production callers - neither does `withUserToken`, which is parked for reactive refresh - so the adapter existed only to be tested. Inline it. Its one test the union's own tests didn't already cover (a successful within-skew refresh persisting the new pair) moves to the `resolveSessionToken` block. Also states the login-is-expensive invariant once, on the union that encodes it, rather than re-deriving it at each site that reports one of these states; `refresh_failed` now records that whether its refresh token is still usable depends on `cause`. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Conflict was the api-context.ts import block only. Resolved as the union of both sides, minus `getValidUserToken`, which this branch inlined into `withUserToken` and deleted. `OAuthError` goes back to a type-only import: main needed it as a value for an `instanceof` check in the old `sessionToken`, which `resolveSessionToken` now owns. main's new `putResourceWithVersion` resolves through `resolveApiContext`, so it picks up the auth-context stamping and reports a 401 as `credential_rejected` with no extra wiring. Its two clarification legs compose with that: `clarifyVersionConflict` gates on `ExitCode.ApiClient` so an auth failure passes through untouched, and `clarifyVersionReadFailure` spreads the underlying error, keeping the code, exit, and `environment` while explaining nothing was written. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
| try { | ||
| const store = opts.store ?? resolveStore(); | ||
| const session = await store.load(other); | ||
| if (!session?.accessToken) return undefined; |
There was a problem hiding this comment.
store.load() returns the raw TOML object without interpreting expiresAt, so session.accessToken can be truthy on an expired slot.
otherEnvHint only checks for a present token, so it can hint at an environment that would also fail.
I think the most maintainable fix is probably to call resolveSessionToken on the other slot and only hint when it comes back ok (that keeps all the validity logic consolidated). The wrinkle is that resolveSessionToken can trigger a proactive refresh, so it may be worth a small refactor there to support a read-only path.
There was a problem hiding this comment.
Good catch - a stale slot did read as usable, and the hint pointed straight at a second wall. Fixed in ae8480f, taking the route you suggested.
resolveSessionToken is now split: classifySession(session, now) is the file-only verdict, and resolveSessionToken is that plus the refresh attempt. The state that needed carving out is refreshable - at or near expiry with a refresh token and client creds - since only a request can settle it. SessionState is SessionOutcome minus refresh_failed plus that one, so the two can't drift.
sessionUsable(store, env, now) is the read-only path: it classifies and answers true for ok or refreshable, without touching the network. otherEnvHint gates on it now instead of on a truthy access token. refreshable counts as usable deliberately - --env sandbox renews it on the way through, so an expired-but-renewable slot is still the right thing to point at.
Two smoke tests were asserting the old hint: their fixture had both slots expired with no refresh token, which is precisely the state that shouldn't produce one. writeCredentials now takes per-slot expiries, so the both-expired case asserts no hint and two new fixtures cover the hint in each direction. Still no network calls in that describe - moving one slot's expiry forward only changes the other slot's error.
`otherEnvHint` read the other slot's raw TOML and treated a truthy access token as a usable session. A slot carries whatever the file says, so a token that expired weeks ago reads as present and the hint sent the caller from one wall to the next. Split the file-only verdict out of `resolveSessionToken` as `classifySession`, with a `refreshable` state for the near-expiry-with-refresh-token case that only a request can resolve. `resolveSessionToken` acts on it; `sessionUsable` reads the same verdict without touching the network, so the hint can't rotate a credential nobody asked us to touch. Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Summary
no_access_token— "rungusto auth login". Logging in mints a new pair and overwrites the stored refresh token, so an agent that followed that instruction destroyed the state that would have recovered, and each retry left the install worse than the last. Split into three codes that call for different actions:no_access_token(nothing on file),session_expired(expired, can't be renewed locally),token_refresh_failed(refresh attempted and rejected, refresh token still on file). All keep exit code 3.token_refresh_failed's recovery depends on why the refresh was rejected. Where the server turned down the attempt (a 5xx,temporarily_unavailable), the refresh token is still good and the message says to retry first — the retry is free, andauth loginis not: it needs a human at a browser an agent on a headless box can't produce, and a successful one invalidates the refresh token it replaces. Where the server turned down the token (invalid_grant— per RFC 6749 an invalid, expired, or revoked grant, and the reason a dead refresh token actually comes back with), a retry re-runs the same refresh and fails identically, so the message points atauth logininstead and notes that the credential the login replaces is already dead. Same code either way: what differs is the recovery, not the state a caller branches on.401from the API had the same problem one layer out: it fell through to the generic 4xx bucket asapi_client_errorat exit4, the same envelope a malformed request gets, with no environment and no guidance. It's nowcredential_rejectedat exit 3, joining the403 insufficient_scopecase that has always classified this way. The message names which credential was refused, because the recovery differs — a stored session can be signed in again, while a token fromGUSTO_ACCESS_TOKENor--token-stdinis the caller's to fix and must not be answered withauth login, which would rotate a session the failing command never used. Nothing re-authenticates a rejected token on its own, so nothing here suggests a bare retry; that's the opposite oftoken_refresh_failed, where the retry is sometimes the point. For a stored session the message also names what the login costs: a 401 doesn't prove the slot's refresh token is bad, and the login replaces it.auth whoaminow returnsenvironment, auth errors carry it in a typedenvironmentfield and name the credential slot they read, and theauthsubcommands document--envand the per-environment credential slots. When the requested environment has no usable session but the other does, the error hints at it.config.toml'senvironmentkey, which was validated and persisted and then read by nothing — so the recovery that hint recommends actually works. Precedence:--env>GUSTO_ENVIRONMENT> config > production, resolved by commander itself (--envoutranks.env(), which outranks.default()), so no new state carries it.Breaking: a 401 now exits
3where it used to exit4. Anything branching on4to mean "bad request" stops seeing 401s there; anything branching on3for credential trouble starts catching them, which is the point. Two existing tests asserted the old classification (the MCP gateway's401 → api_client_error, andwhoami'stoken_infoerror) and now assert the new one — flagging that explicitly, since it's the evidence this is a contract change rather than an addition.README.mddocumented4for all API 4xx, which was already untrue of the403scope case; it now says what actually decides it.Behavior change worth flagging: an expired token with no refresh token used to be sent to the API anyway and 401 back. It's now reported as
session_expiredwithout a request. One existing test asserted the old collapsed behavior and has been rewritten to cover the real refresh-failure path (it had been simulating a refresh failure by makingstore.load()throw, which never happens with a real store).Implementation notes.
ApiErrorthe client throws, the wayrequestIdalready is, rather than threaded through call sites as a parameter. EverytoResultcaller then reports a 401 identically — includinggusto api,report,ledger,payrolland the MCP surface, which sit several frames from a resolved context — andtoResult's signature is unchanged.requestIdandauthride in one optional context object, since four required positional args are already the limit of what reads unlabeled at a call site.environmentdefault is the--envoption's.default(), installed once inbuildProgramfrom the config read inmain(). Commander then resolves the whole precedence chain, soreadGlobalFlagsjust passes the value through and nothing new is cached. The default is only installed when one is actually persisted, so an unset--envstaysundefinedanddefaultEnvkeeps sole ownership of the production fallback.resolveSessionTokenreturns the discriminatedSessionOutcomeand is what commands resolve through.getValidUserTokenused to sit on top of it flattening three failure kinds into null-or-throw; withresolveSessionTokenin place that adapter only existed to be tested, so it's inlined intowithUserToken, its one caller.Linked issue
AINT-830
Follow-ups filed rather than folded in:
withUserTokenalready implements reactive refresh-on-401 and still has no production caller. Deliberately left out of this PR: it only helps where the credential is recoverable (clock skew, an absentexpiresAt, a long command crossing expiry), while a server-side revocation fails either way — whereas correct classification helps every case. Wiring it in also isn't a hookup:withUserTokenreplays a whole operation, so a late 401 in a paginated walk or apoll()would replay the entire command, writes included. It belongs per-request insideApiClient, which means the token can no longer be baked in at construction. Until it exists,credential_rejectedon a stored session can't offer anything cheaper than a login, and says so.config set formatis dead the same wayenvironmentwas.Test plan
bun run test:allpasses locally — 1370 pass, 1 skip (pre-existing), 0 fail--agentand--humanoutput verified where touchedManual verification ran the compiled binary against a synthetic credentials file under a temp
XDG_CONFIG_HOME, with OAuth traffic pointed at a local stub, so no real refresh token was rotated:session_expired, dated, names the slot503 temporarily_unavailable→token_refresh_failed, retry-first wording, and the stored refresh token still on file afterward — the actual regression400 invalid_grant→token_refresh_failed, login wording naming the token as already dead, no retry advice, refresh token still on filedetailshintnaming the other slotconfig set environment sandboxthen a bare command resolves sandbox;GUSTO_ENVIRONMENToverrides it;--envoverrides both; no config resolves production; a corrupt config warns on stderr and falls back rather than abortingFor the 401 path, a local stub returning
401with a request id, hit through the compiled binary:GUSTO_ACCESS_TOKEN→credential_rejected, exit 3, names the env var,environmentandrequest_idpopulated, noauth loginsuggested--token-stdin→ same, naming that flag instead--humanmode, where the envelope'senvironmentfield isn't printed and the message has to carry itThe same expired-production-plus-valid-sandbox state is covered as a regression test at both the unit level and through the compiled binary.
DCO
git commit -s) per the DCO🤖 Generated with Claude Code